Python curl_cffi 實作:Browser Impersonation、Session、Asyncio 與 WebSocket

將瀏覽器中的 API 請求搬到 Python,通常會先對照 URL、Header、Cookie 與 Request Body。這些內容能由程式直接設定,TLS 握手與 HTTP/2 連線參數卻主要由 HTTP library 決定;即使把 User-Agent 換成 Chrome,底層也可能保留原本的實作特徵。

curl_cffi 把這一層設定帶進 Python。程式可以沿用熟悉的 get()post() 與 Session 操作,同時透過 impersonate 套用指定瀏覽器的 Transport Profile。實際使用時,仍需要確認套用的是哪個版本、伺服器觀察到什麼,以及 Cookie、Proxy、併發與資源生命週期如何配合。

這些設定的原理可對照 HTTP Client 與瀏覽器指紋:TLS ClientHello、JA3/JA4、HTTP/2 與 HTTP/3。到了程式端,驗證重點是讓 API 參數、實際連線與診斷結果能互相對照。

Quick Start

使用 Python 3.10 以上,在專用目錄建立虛擬環境並安裝固定版本:

python3 -m venv .venv
source .venv/bin/activate
python -m pip install 'curl_cffi==0.16.3'

將以下程式存成 quick_start.py,以一筆 GET 查看公開診斷端點收到的連線特徵:

from curl_cffi import get, CurlHttpVersion

response = get(
    "https://tls.browserleaks.com/json",
    impersonate="chrome124",
    timeout=15,
)
response.raise_for_status()
data = response.json()

print("HTTP version:", CurlHttpVersion(response.http_version).name)
print("JA3N:", data.get("ja3n_hash"))
python quick_start.py

impersonate="chrome124" 套用固定版本的 Browser Profile,timeout=15 設定這次傳輸的 15 秒上限;raise_for_status() 則在收到 HTTP 4xx/5xx 時拋出錯誤。

若 HTTP 版本輸出 V2_0,代表實際使用 HTTP/2;JA3N 是診斷端點根據 TLS 握手計算的摘要。這筆請求由 Python API 接收參數,再交由底層連線元件完成傳輸。

curl_cffi 架構與實作環境

Python API 與底層連線元件

curl_cffi 透過 CFFI 呼叫 curl-impersonate fork 的 libcurl。Python 處理請求參數與回應物件,底層 C library 負責實際連線及可調整的 TLS、HTTP 行為,呼叫過程不需要啟動命令列 subprocess。

Python 程式
    ↓
Session/AsyncSession/Low-level Curl API
    ↓
CFFI
    ↓
curl-impersonate fork/libcurl
    ↓
TLS、HTTP/1.1、HTTP/2、HTTP/3 與 socket

同步 Session 適合依序執行請求。AsyncSession 則透過 AsyncCurl 整合 libcurl Multi Interface 與 asyncio event loop,讓多個傳輸共用非同步事件處理;它不是替每一個 Request 開一條 Python thread。套件說明AsyncCurl API 與 libcurl Multi Interface 分別描述這幾層的責任。

版本固定與安裝

2026-09-08 查核 PyPI 時,最新非預發布版本為 0.16.3,發布日期為 2026-09-02,最低需求為 Python 3.10。實測環境如下;這是測量結果所屬的環境,不代表所有平台的 Wheel 都具有完全相同的底層組成。

項目 實測值
作業系統 Ubuntu 22.04.5 LTS,x86_64
Python CPython 3.10.12
curl_cffi 0.16.3
Requests 對照組 2.34.2
libcurl 8.21.0-IMPERSONATE
TLS backend BoringSSL
HTTP/2 library nghttp2 1.63.0
QUIC/HTTP/3 library ngtcp2 1.20.0、nghttp3 1.15.0
TLS/HTTP/2 固定實驗 Profile chrome124
HTTP/3 固定實驗 Profile chrome150

沿用 Quick Start 的虛擬環境,再安裝 CLI 與 Requests 對照組所需的套件:

python -m pip install 'curl_cffi[cli]==0.16.3' 'requests==2.34.2'
python -m pip check

[cli] 安裝命令列工具所需的額外依賴。相同固定版本已放在配套的 requirements.txt;需要保存完整環境時,再將 python -m pip freeze 的結果存入自己的 lock/環境紀錄。只固定頂層套件,不等於所有間接依賴都已鎖定。

以下程式列出 Python、套件與真正使用的 libcurl 版本:

import platform
import curl_cffi
from curl_cffi import Curl

print(platform.python_version())
print(curl_cffi.__version__)
curl = Curl()
try:
    print(curl.version().decode())
finally:
    curl.close()

Curl 在 0.16.3 沒有一般 with Curl() 的 Context Manager 介面,低階 handle 要使用 try/finally 關閉。作業系統的 curl --version 顯示的是另一個執行檔,不能代替上面的檢查。

Browser Target 與 Profile 資料

安裝後先列出本機實際可用的 Target:

curl-cffi list

實測清單包含 chrome124chrome146chrome150firefox147 等內建 Profile,也會列出作業系統資訊與 h3_fingerprints 欄位。chrome124 的 HTTP/3 指紋標記為 falsechrome150 為 true。能協商 HTTP/3,與具有該瀏覽器的 QUIC/HTTP/3 Profile,是兩個不同條件。

impersonate="chrome" 是方便追隨預設 Target 的名稱;impersonate="chrome124" 則指定版本。這裡選擇 chrome124 是為了固定 TLS/HTTP/2 對照條件,不是把舊版 Chrome 當成現行使用者的代表。Profile 清單也不會涵蓋每一次瀏覽器 release。官方 Target 說明 有列出版本選擇與更新方式。

從 0.15.1 起,Profile 資料還能透過 curl-cffi update 更新。因此,可重現環境除了套件版本,還要記錄本機 Target 清單、是否下載過額外 Profile,以及所用 JSON 資料的版本或 checksum。套件未升級,並不保證本機 Profile cache 完全沒變。

Browser Impersonation 與指紋對照

四組 Client 的比較

先把變因限縮成 Client 與 User-Agent,向公開的 BrowserLeaks TLS 診斷端點送出四筆 GET:一般 Requests、只修改 Header 的 Requests、未指定 Profile 的 curl_cffi,以及固定 chrome124 的 curl_cffi

import json
import requests
from curl_cffi import Session, CurlHttpVersion, CurlOpt

URL = "https://tls.browserleaks.com/json"
UA = (
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
    "AppleWebKit/537.36 (KHTML, like Gecko) "
    "Chrome/124.0.0.0 Safari/537.36"
)
FIELDS = ("ja3_hash", "ja3n_hash", "ja4", "akamai_hash")


def show(name, response, version):
    response.raise_for_status()
    data = response.json()
    print(json.dumps({
        "client": name,
        "http_version": version,
        "fingerprints": {key: data.get(key) for key in FIELDS},
    }, ensure_ascii=False))


for name, headers in (("requests", {}), ("requests_ua", {"User-Agent": UA})):
    with requests.Session() as session:
        session.trust_env = False
        response = session.get(URL, headers=headers, timeout=(5, 15))
        version = {10: "1.0", 11: "1.1", 20: "2"}.get(response.raw.version)
        show(name, response, version)

for name, profile in (("curl_cffi", None), ("chrome124", "chrome124")):
    with Session(curl_options={CurlOpt.PROXY: ""}) as session:
        response = session.get(URL, impersonate=profile, timeout=15)
        show(name, response, CurlHttpVersion(response.http_version).name)

這個小範例只輸出指紋摘要,不列印端點回傳的完整資料。CurlOpt.PROXY: "" 明確要求 libcurl 直連,方便固定比較條件;需要經過公司 Proxy 的環境,應改成已知設定並記錄其 TLS 工作模式。

2026-09-08,在上述 Ubuntu 環境的觀察結果如下:

Client 實際 HTTP 版本 JA3N Hash Akamai Hash
Requests HTTP/1.1 62fcc66dfa1611e219a93df2d1bb1b24 空值,未建立 HTTP/2
Requests+Chrome User-Agent HTTP/1.1 62fcc66dfa1611e219a93df2d1bb1b24 空值,未建立 HTTP/2
curl_cffi,未指定 Profile HTTP/2 4fd7cab6c51893a22b46123125be5bae 52d84b11737d980aef856699f885ca86
curl_cffi,chrome124 HTTP/2 4c9ce26028c11d7544da00d3f7e4f45c 52d84b11737d980aef856699f885ca86

Requests 的兩組結果顯示,只修改 User-Agent 沒有改變這次量測的 TLS 摘要。未指定 Profile 的 curl_cffi 和 chrome124 則具有不同的 JA3N,但這次 Akamai Hash 相同:切換 Profile 不代表每一層的每一個摘要都一定變動。

chrome124 的重複觀測還出現不同 JA3 Hash,但 JA3N 與 JA4 保持相同;這與 Extension Permutation 的特性一致。這些數值是特定日期、版本與診斷服務的觀察值,不應寫進應用程式當成永久驗收常數。這次也沒有同版真實 Chrome 的封包對照,因此結果能證明 Profile 改變了觀測特徵,不能宣稱完整重現了真實瀏覽器。

預設 Header 與請求情境

套用 Profile 時,default_headers=True 會一併加入該 Target 的預設 Header。Request 的 headers 可以覆寫其中的值;default_headers=False 則停用 Profile 預設 Header,但 libcurl 仍可能產生 HostAccept 等傳輸所需或自身預設欄位。

from curl_cffi import Session

with Session(impersonate="chrome124", timeout=15) as session:
    response = session.get(
        "https://httpbin.org/headers",
        headers={"Accept": "application/json"},
    )
    response.raise_for_status()
    assert response.json()["headers"]["Accept"] == "application/json"

這裡的 Accept 表達期望 JSON 回應。真正的 Browser Profile 還可能包含 Client Hints 與 Sec-Fetch-* 等欄位;API Fetch、首頁導覽和圖片下載的情境不同,不能只改 Accept 就認定整組 Header 都符合該請求。若自行停用預設 Header,就需要負責整套宣告的一致性。

若操作需要執行 JavaScript、讀取 DOM、使用 Canvas/WebGL,或完成依賴前端狀態的互動流程,就需要實際的瀏覽器執行環境,例如使用 Playwright 操作瀏覽器。這是 Runtime 的需求,無法透過調整 TLS Profile 補足。

HTTP 請求、Response 與 Session

Request Body 與回應解析

API 外觀接近 Requests,但資料型別與資源處理仍要依實測版本確認。下列範例以 httpbin 的 echo 行為示範 Query、Form、JSON 與 Raw Body;可以透過 HTTP_ECHO_BASE 改成提供相同介面的自有服務。

import os
from curl_cffi import Session

BASE = os.environ.get("HTTP_ECHO_BASE", "https://httpbin.org")

with Session(impersonate="chrome124", timeout=15) as session:
    query = session.get(f"{BASE}/get", params={"page": 1, "tag": ["tls", "http2"]})
    query.raise_for_status()
    assert query.json()["args"]["tag"] == ["tls", "http2"]

    form = session.post(f"{BASE}/post", data={"name": "sample"})
    form.raise_for_status()
    assert form.json()["form"]["name"] == "sample"

    payload = session.post(f"{BASE}/post", json={"enabled": True, "count": 2})
    payload.raise_for_status()
    assert payload.json()["json"]["count"] == 2

    raw = session.post(
        f"{BASE}/post",
        content=b"sample body",
        headers={"Content-Type": "application/octet-stream"},
    )
    raw.raise_for_status()
    assert raw.json()["data"] == "sample body"

    for method in ("PUT", "PATCH", "DELETE"):
        response = session.request(method, f"{BASE}/anything", json={"sample": True})
        response.raise_for_status()
        assert response.json()["method"] == method

params 負責 URL 查詢參數;data=dict 編碼 Form;json 執行 JSON 序列化並設定對應 Content-Type;content 接受原始 bytes 或支援的串流來源。同一筆請求應依實際資料契約選一種 Body 表達方式,避免同時傳入互相衝突的參數。Quick Start 與 0.16.3 參數轉換原始碼 可核對實際處理流程。

Response 常用介面如下:

介面 用途與限制
status_coderaise_for_status() 取得 HTTP 狀態、將 4xx/5xx 轉成 HTTPError;取得 Response 不等於業務成功
headers 以大小寫不敏感方式讀取回應 Header
content 取得緩衝後的 bytes;大型回應需考慮記憶體
textencoding 解碼文字;需要覆寫 encoding 時,應在首次讀取 text 前設定
json() 解析 JSON;200 回應仍可能含 HTML 或格式錯誤資料
urlhistory 最終 URL 與重導歷程;history 保留狀態、URL、Header,不保留中間 Body
cookies 目前 Response 的 Cookie;跨重導與多次請求的累積狀態應查看 Session Cookie Jar
elapsed 0.16.3 為 timedelta,以 elapsed.total_seconds() 取得秒數
http_version libcurl 列舉值,應用 CurlHttpVersion(...) 轉換,不要直接把整數當 HTTP 版本

Basic Authentication 使用 auth=(username, password);真實帳密應從環境設定或秘密管理服務取得,只透過 HTTPS 傳送,也不應將 tuple、Authorization Header 或完整 Response dump 到 log。

Timeout 與 Redirect

curl_cffi 0.16.3 的 timeout 二元組不能直接當成 Requests 的「connect timeout、每次讀取等待時間」。實際轉換如下:

設定 stream=True stream=True
timeout=15 設定整筆 transfer 的 15 秒上限 連線逾時 15 秒,另以低速條件控制持續傳輸
timeout=(3, 12) Connect 上限 3 秒,整筆 transfer 上限 15 秒 Connect 上限 3 秒,低於 1 byte/s 持續約 15 秒時中止
timeout=None 停用對應 timeout 不適合作為無人值守工作的預設

二元組的第二個值雖然在原始碼命名為 read_timeout,非串流模式卻會和 connect 值相加,形成總上限;串流模式則使用 LOW_SPEED_LIMIT 與 LOW_SPEED_TIME。有資料緩慢流動的串流可能持續很久,不能把這組設定當成工作總 deadline。0.16.3 實作libcurl TIMEOUT 與 LOW_SPEED_TIME 說明了兩種限制的差別。

Redirect 預設會跟隨,固定診斷對象時可以關閉:

from curl_cffi import Session

with Session(timeout=15, allow_redirects=False) as session:
    response = session.get("https://httpbin.org/redirect/1")
    assert response.status_code == 302

    response = session.get(
        "https://httpbin.org/redirect/2",
        allow_redirects=True,
        max_redirects=3,
    )
    response.raise_for_status()
    assert [item.status_code for item in response.history] == [302, 302]

允許 Redirect 時,最終目的地可能改變。攜帶帳密、Cookie 或可重送 Body 的程式,還要考慮跨主機跳轉與重新傳送的條件;raise_for_status() 本身不會把 302 視為失敗。

Cookie Jar 與連線重用

多次操作同一個服務時,Session 可以延續 Cookie 並重用底層連線。Session 預設參數與 Request 覆寫的責任可以分開:Profile、Proxy 與身分維持固定,單次 Request 再提供路徑、Body 與必要 Header。

from curl_cffi import Session

with Session(impersonate="chrome124", timeout=15) as session:
    response = session.get("https://httpbin.org/cookies/set/article/demo")
    response.raise_for_status()
    response = session.get("https://httpbin.org/cookies")
    response.raise_for_status()
    assert response.json()["cookies"]["article"] == "demo"

    session.cookies.set("theme", "dark", domain="httpbin.org", path="/")
    assert session.cookies.get("theme", domain="httpbin.org", path="/") == "dark"
    session.cookies.delete("theme", domain="httpbin.org", path="/")

Cookie 名稱相同、Domain 或 Path 不同時,可能對應多筆值,因此管理 Cookie 時應保留範圍條件。不同帳號、Cookie 歷史、Proxy 或 Profile 應使用獨立 Session,避免同一連線池和狀態容器混入互不相干的身分。

Keep-Alive 重用的是既有連線;TLS Session Resumption 可能在新連線重用先前協商狀態;HTTP/2 multiplexing 則是在同一條連線上同時處理多個 stream。三者不等價,只有依序執行 session.get(),並不代表已測到並行 multiplexing。

同步 Session 的文件標示 thread-safe,實作預設使用 thread-local Curl handle,但官方仍建議每個 thread 使用獨立 Session。共享 Cookie Jar 與應用程式身分的邏輯一致性,也需要呼叫端自己管理。Asyncio 的 AsyncSession 則應留在建立它的 event loop 內使用。Session 原始碼與介面說明 可核對這些生命週期。

Multipart 檔案上傳

Multipart 使用 CurlMime,而不是 Requests 的 files=。檔案路徑與記憶體資料分別以 local_pathdata 指定;同一 part 選擇其中一種來源。

from pathlib import Path
from tempfile import TemporaryDirectory
from curl_cffi import CurlMime, Session

with TemporaryDirectory() as folder:
    path = Path(folder) / "sample.txt"
    path.write_text("sample file", encoding="utf-8")
    multipart = CurlMime()
    try:
        multipart.addpart(
            name="attachment", filename="sample.txt",
            content_type="text/plain", local_path=str(path),
        )
        multipart.addpart(
            name="metadata", filename="metadata.json",
            content_type="application/json", data=b'{"sample":true}',
        )
        with Session(timeout=15) as session:
            response = session.post(
                "https://httpbin.org/post",
                data={"label": "article"}, multipart=multipart,
            )
            response.raise_for_status()
            assert response.json()["files"]["attachment"] == "sample file"
    finally:
        multipart.close()

重複呼叫 addpart() 即可加入多個檔案,是否使用相同欄位名稱取決於 Server 契約。Boundary 交由 libcurl 建立,不要另外寫一個缺少或不符 Boundary 的 Content-Type。大型檔案優先提供路徑,避免先 read() 整個檔案放進 Python 記憶體;檔案在傳輸完成前必須保持可讀。

Proxy、TLS 與 HTTP 版本

Proxy 與 DNS 解析位置

proxy 設定單一代理,proxies 則可依 URL scheme 配置。HTTPS 經 HTTP Proxy 通常使用 CONNECT tunnel;http://proxy:3128 中的 scheme 描述連到 Proxy 的方式,不代表目標站只能使用 HTTP。

import os
from curl_cffi import Session

with Session(proxy=os.environ["HTTP_PROXY_URL"], timeout=15) as session:
    response = session.get("https://httpbin.org/get")
    response.raise_for_status()
    print(response.status_code)

HTTP_PROXY_URL 是使用者提供的測試 Proxy 位址;需要認證時可另傳 proxy_auth=(username, password)。SOCKS 使用 socks5:// 時,由 Client 解析目標 hostname;socks5h:// 交由 Proxy 解析。Proxy 的 DNS 行為、出口 IP 與是否攔截 TLS,都會影響指紋實驗的觀測條件。

trust_env=False 在 0.16.3 不能被當成「libcurl 完全忽略所有環境設定」的保證。需要明確直連時使用獨立 Session,設定 curl_options={CurlOpt.PROXY: ""};這個低階選項會覆蓋高階 Proxy 設定,不應放進同時需要使用 Proxy 的 Session。libcurl Proxy 選項 定義了空字串停用 Proxy 的行為。

CA 驗證與 mTLS

一般 HTTPS 預設驗證 Server 憑證。0.16.3 的預設 CA 來源可能受到 SSL_CERT_FILECURL_CA_BUNDLEREQUESTS_CA_BUNDLE 影響,否則再依 Python 預設 CA 路徑與 certifi 選擇,不能假設每個環境都只使用 Wheel 內同一份 CA。連到自有 CA 簽發的服務時,明確提供信任 CA 檔案;需要 mTLS 時,再提供 Client Certificate 與 Private Key。

import os
from curl_cffi import Session

with Session(
    verify=os.environ["TEST_CA_FILE"],
    cert=(os.environ["TEST_CLIENT_CERT"], os.environ["TEST_CLIENT_KEY"]),
    timeout=15,
) as session:
    response = session.get(os.environ["TEST_MTLS_URL"])
    response.raise_for_status()
    print(response.status_code)

四個環境變數依序代表 CA 信任檔、Client 憑證、Client 私鑰與自有 mTLS 測試 URL。CA 驗證回答「是否信任 Server」,Client 憑證則讓 Server 驗證呼叫端;Profile 不會替代其中任何一項。verify=False 會失去 Server 憑證驗證,不應拿來修補 CA 路徑或 hostname 配置錯誤。

HTTP/1.1、HTTP/2 與 HTTP/3

明確指定 HTTP 版本後,仍要讀取 response.http_version,確認最後協商的結果。V2_0 與 V3 具有 fallback 語意;V3ONLY 則要求 HTTP/3,不能降回 TCP 上的 HTTP。libcurl HTTP_VERSION 也提醒,既有連線重用可能影響請求結果,因此不同 protocol 實驗適合建立獨立 Session。

http_version 設定 新連線的要求
CurlHttpVersion.V1_1 使用 HTTP/1.1
CurlHttpVersion.V2_0 嘗試 HTTP/2,未協商成功可退回 HTTP/1.1
CurlHttpVersion.V3 嘗試 HTTP/3,允許退回較早版本
CurlHttpVersion.V3ONLY 只嘗試 HTTP/3,失敗時不降級
from curl_cffi import Session, CurlHttpVersion, CurlOpt

with Session(curl_options={CurlOpt.PROXY: ""}, timeout=12) as session:
    response = session.get(
        "https://fp.impersonate.pro/api/http3",
        impersonate="chrome150",
        http_version=CurlHttpVersion.V3ONLY,
    )
    response.raise_for_status()
    assert response.http_version == CurlHttpVersion.V3
    data = response.json()
    print({"status": response.status_code, "version": "HTTP/3", "fields": list(data)})

2026-09-08 的實測回傳 HTTP 200,實際版本為 V3,JSON 最上層包含 infoprotocolquichttp3 與 tls。這證明該環境能以指定 Client 完成 HTTP/3 診斷請求;要認定 QUIC Transport Parameters、HTTP/3 SETTINGS 與真實 Chrome 相同,仍需要逐欄對照。

若 UDP 出口、Proxy 或 Server 不支援 HTTP/3,V3ONLY 可能失敗。一般 HTTP CONNECT Proxy 不能自動承載 QUIC;套件提供的 UDP Proxy 能力也需要 Proxy 端配合,不能只把 HTTP Proxy URL 填入就假設成立。

DoH、來源介面與傳輸參數

高階 Request 還提供以下控制。這些設定必須和本機網路及 Server 契約配合,不能只靠參數名稱推定實際封包。

參數 用途
doh_url 使用指定 DNS-over-HTTPS resolver;仍要考慮 resolver 位址解析與 Proxy 的 DNS 模式
interface 綁定網路介面或本機來源位址;該介面/IP 必須存在且有可用路由
quote 控制 URL quoting;False 停用額外 quote 行為,呼叫端需提供有效 URL
accept_encoding 宣告支援的壓縮格式,配合套件的解壓能力
max_recv_speed 接收速率限制,單位為 bytes/s;不是同站所有 Client 的全域速率限制
referer 設定 Referer,需符合實際請求情境
curl_options Session 層補充高階 API 未提供的 libcurl options,須避免和既有參數衝突

interface="127.0.0.1" 只適合連到本機可達的測試服務,不能用它建立一般外網連線。切換來源 IP、Proxy 或 Profile 時,重新建立對應 Session,也能避免把先前的 connection pool 狀態帶入新實驗。

Asyncio、Streaming 與 WebSocket

AsyncSession 與有界併發

當請求彼此獨立,可以使用 AsyncSession 同時等待多個網路回應。併發設計需要同時限制 Python Task 與底層傳輸;只設定 max_clients,不會阻止程式先建立數十萬個等待中的 Task。

以下以小批次處理 URL,每一批最多三筆,批內結果保留輸入順序:

import asyncio
from curl_cffi import AsyncSession
from curl_cffi.requests.exceptions import RequestException


async def fetch_many(urls, concurrency=3):
    if concurrency < 1:
        raise ValueError("concurrency must be positive")
    results = []
    async with AsyncSession(
        impersonate="chrome124", max_clients=concurrency, timeout=10
    ) as session:
        async def fetch(index, url):
            try:
                response = await session.get(url)
                response.raise_for_status()
                return {"index": index, "status": response.status_code, "error": None}
            except RequestException as error:
                return {"index": index, "status": None, "error": type(error).__name__}

        for start in range(0, len(urls), concurrency):
            batch = urls[start:start + concurrency]
            results.extend(await asyncio.gather(*(
                fetch(start + offset, url) for offset, url in enumerate(batch)
            )))
    return results

這個函式適合有限且回應不大的 URL 清單;它仍會保存完整輸入與結果,並由普通 get() 緩衝 Body。大量工作可以改成有容量限制的 asyncio.Queue 與固定數量 worker,再由單一 writer 持續輸出。Semaphore 適合限制共享資源,但若外層一次建立全部 Task,仍沒有控制 Task 本身的記憶體。

max_clients 表示可同時借用的 Curl handle 數量,不能直接當成 TCP 連線數或 HTTP/2 stream 數。整批工作需要總 deadline 時,可在 Python 3.10 使用 asyncio.wait_for(fetch_many(...), timeout=...)。取消應向外傳遞,讓 async with 執行清理,不能將 CancelledError 當一般可重試的網路錯誤吞掉。

Streaming Download 與記憶體上限

stream=True 或 session.stream() 可以逐塊消費回應:

from curl_cffi import Session, CurlOpt

with Session(curl_options={CurlOpt.TIMEOUT_MS: 15_000}) as session:
    with session.stream("GET", "https://httpbin.org/bytes/4096", timeout=10) as response:
        response.raise_for_status()
        received = 0
        for chunk in response.iter_content():
            received += len(chunk)
        assert received == 4096

TIMEOUT_MS 額外設定整筆 transfer 上限,補足串流模式的低速 timeout。Context Manager 也確保提早停止迭代時關閉 Response。

但迭代介面本身不保證固定記憶體。0.16.3 使用內部無界 queue 將 libcurl callback 轉成 chunk iterator;Consumer 比網路慢時,資料仍可能堆積。WebSocket 的 queue-size 參數不能直接套用到 HTTP streaming。官方 Streaming 說明 明確記錄了這個限制。

對下載寫檔,可以直接使用同步 content_callback,在收到資料時檢查大小並寫入暫存檔:

from pathlib import Path
from tempfile import NamedTemporaryFile
from curl_cffi import Session


class DownloadTooLarge(Exception):
    pass


def download(url: str, output: Path, max_bytes: int) -> int:
    if max_bytes <= 0:
        raise ValueError("max_bytes must be positive")
    partial = None
    received = 0
    try:
        with NamedTemporaryFile(
            mode="wb", dir=output.parent, prefix=output.name + ".", delete=False
        ) as destination:
            partial = Path(destination.name)

            def on_chunk(chunk: bytes) -> int:
                nonlocal received
                if received + len(chunk) > max_bytes:
                    raise DownloadTooLarge("response exceeds size limit")
                written = destination.write(chunk)
                if written != len(chunk):
                    raise OSError("incomplete file write")
                received += written
                return written

            with Session(timeout=30, allow_redirects=False) as session:
                response = session.get(url, content_callback=on_chunk)
                response.raise_for_status()
                if not 200 <= response.status_code < 300:
                    raise ValueError("unexpected HTTP status")

        partial.replace(output)
        return received
    finally:
        if partial is not None:
            partial.unlink(missing_ok=True)

output 是最終檔案路徑,父目錄需要先存在;max_bytes 是允許寫入的回應 Body 上限。暫存檔與目的檔位於同一目錄,只有成功接收並確認狀態後才原子替換;中途錯誤會刪除暫存檔,既有目的檔保持原狀。這提供一般檔案可見性的原子更新,不等於已完成斷電耐久性所需的 fsync 流程。

使用不同暫存檔可避免過程互相覆寫,但兩個工作若仍指定相同最終路徑,最後完成者會取代先前檔案。需要禁止覆寫或協調多工時,應在應用程式另外定義輸出所有權。

0.16.3 會將 callback 的 Python exception 傳回呼叫端。不要靠 return 0 中止下載:這個版本對不符長度的普通回傳值可能只發 warning,仍向 libcurl 回報資料已處理。需要以回傳值中止時,應使用該版本的 CURL_WRITEFUNC_ERROR 常數。Write callback 實作 可核對這個差異。

content_callback 即使用在 AsyncSession 也仍是同步 callback,不應直接放入 async def,也不適合執行耗時工作阻塞 event loop。一般小型診斷 Body 可直接在 callback 中累積到明確上限;大量非同步磁碟處理需要另外設計有界緩衝及 producer 暫停策略。

Streaming Upload 與 Body 重送

0.16.3 的 content 已能接受 binary file、同步 bytes iterable;AsyncSession 還能接受 async bytes iterable。高階上傳不必為了逐塊供應資料就一律改寫成 Low-level API。

import asyncio
import os
from curl_cffi import AsyncSession


async def upload():
    async def chunks():
        yield b"first\n"
        await asyncio.sleep(0)
        yield b"second\n"

    base = os.environ.get("HTTP_ECHO_BASE", "https://httpbin.org")
    async with AsyncSession(timeout=15) as session:
        response = await session.post(
            f"{base}/post",
            content=chunks(),
            headers={"Content-Type": "application/octet-stream"},
        )
        response.raise_for_status()
        assert response.json()["data"] == "first\nsecond\n"


asyncio.run(upload())

已知長度時可以提供正確的 Content-Length,未知長度則交由 libcurl 使用所協商版本的串流 framing;HTTP/2、HTTP/3 不使用 HTTP/1.1 的 chunked transfer encoding。

重試或 307/308 Redirect 可能需要重送 Body。可 seek 的 binary file 能回到原始位置,一次性 generator 則未必可重播;0.16.3 對不能回捲的來源會拋出 UnrewindableBodyError。即使來源可以重播,也仍要確認重送請求是否會產生重複副作用。

Async WebSocket 生命週期

WebSocket 先建立 HTTP handshake,再進入雙向訊息傳輸。連線建立的 Profile、Cookie、Proxy 與認證設定,和連線成立後的訊息順序、queue 與重連狀態,是不同層次的責任。

import asyncio
import os
from curl_cffi import AsyncSession, CurlWsFlag


async def exchange(url):
    async with AsyncSession(impersonate="chrome150") as session:
        async with session.ws_connect(
            url,
            timeout=10,
            recv_queue_size=32,
            send_queue_size=32,
            max_message_size=1024 * 1024,
        ) as ws:
            await ws.send("hello", flags=CurlWsFlag.TEXT, timeout=5)
            assert await ws.recv_str(timeout=5) == "hello"
            await ws.send_bytes(b"sample")
            payload, flags = await ws.recv(timeout=5)
            assert payload == b"sample"
            assert flags & CurlWsFlag.BINARY
            await ws.send_json({"sample": True})
            assert await ws.recv_json(timeout=5) == {"sample": True}


asyncio.run(asyncio.wait_for(exchange(os.environ["TEST_WS_URL"]), timeout=20))

TEST_WS_URL 指向自有或授權的 echo WebSocket。配套驗證使用 loopback ws:// 服務,因此驗證的是 API、訊息與生命週期;它不構成 WSS TLS 指紋已和真實瀏覽器對齊的證據。

AsyncSession.ws_connect() 回傳可用於 async with、也可 await 的物件。若使用 ws = await session.ws_connect(...),就要在 finally 裡 await ws.close()。上述結構將兩層關閉責任交給 Context Manager。官方 WebSocket 文件 與 0.16.3 實作 可核對詳細介面。

設定或方法 實際語意
ws_connect(timeout=...) 控制建立連線階段,不等於每筆訊息的接收時限
send_str()send_bytes()send_json() 明確選擇訊息表示方式;一般 send() 預設是 binary,即使 payload 是 Python str
send(..., timeout=...) 限制等待 send queue 空間的時間;回傳不代表 Server 已處理
flush(timeout=...) 等背景 writer 將資料交給 socket;業務確認仍需要 Server ACK
recv_str(timeout=...) 限制等待下一則訊息的時間
recv_queue_sizesend_queue_size queue 容量以訊息數計算,不是 bytes
max_message_size 限制完整 incoming message,無法代替 outgoing payload 大小限制
block_on_recv_queue_full=True 接收 queue 滿時等待 Consumer,形成 backpressure
async for message in ws 逐則取得 bytes;需要明確 timeout 時使用 recv 方法

接收 queue 滿時設為不等待,會導致錯誤,而不是默默捨棄訊息。對外送資料也應限制單則 bytes,避免少量超大訊息占滿記憶體。

ping() 將 Ping 放入傳送流程,不代表已等到 Pong;Pong 也不是一般 recv() 的應用訊息。Close frame 負責結束協定會話,Context Manager 再完成資源清理。重新連線後,訂閱、最後事件 ID、去重與補資料都需要應用程式恢復,不能把 transport retry 當成業務狀態已回復。

自訂 Fingerprint 與 Low-level API

內建 Target 與可編輯資料

一般需求優先選定內建 Target,避免逐項重建 TLS 與 HTTP 設定。需要編輯完整 Profile 時,要區分 libcurl 內建名稱,和儲存在本機快取中的完整 Fingerprint 資料。

0.16.3 的 get_fingerprint("chrome146") 對內建 Target 可以回傳 metadata 與預設欄位,但不會將 libcurl 裡的完整 Profile 反解出來。實測取得的 tls_ciphersheaders 與 http2_settings 為空,因此不能把它轉存後宣稱已匯出完整 Chrome Profile。

對已取得、可信任且內容完整的同版本 JSON,可使用標準 dataclass 序列化:

import json
import os
from dataclasses import asdict
from pathlib import Path
from curl_cffi import Fingerprint

source = Path(os.environ["FINGERPRINT_SOURCE"])
target = Path(os.environ["FINGERPRINT_OUTPUT"])
fingerprint = Fingerprint(**json.loads(source.read_text(encoding="utf-8")))
fingerprint.headers["Accept-Language"] = "zh-TW,zh;q=0.9"
with target.open("x", encoding="utf-8") as destination:
    json.dump(asdict(fingerprint), destination, ensure_ascii=False, indent=2)

FINGERPRINT_SOURCE 指向自行管理的 Profile JSON,FINGERPRINT_OUTPUT 是尚未存在的輸出檔;後續可將載入的物件傳入 session.get(..., impersonate=fingerprint)Fingerprint 沒有 save()load() 方法,dataclass 也不會代替完整的資料驗證。這段序列化流程已用自有測試資料驗證,沒有下載或驗證商業 Profile。

Profile JSON 不應混入真實 Cookie、Authorization 或其他帳密。跨套件版本載入時,要重新確認 schema、預設值與底層支援能力。Fingerprint 管理文件 與 0.16.3 fingerprints.py 說明了資料來源與快取機制。

JA3、Akamai、Extra Fingerprint 與 HTTP/3

當目標是重現自有程式或授權測試中的已知封包,可以直接提供各層設定:

參數 輸入資料
ja3 五組欄位的原始 JA3 字串,不能填入 MD5 Hash
akamai SETTINGS、WINDOW_UPDATE、PRIORITY、pseudo-header order 的原始字串
extra_fp JA3/Akamai 未涵蓋的額外 TLS 或 HTTP 設定
perk HTTP/3 SETTINGS、pseudo-header order、QUIC Transport Parameters 三段原始資料

perk 以 | 分隔三段資料,並不等於啟用 HTTP/3;仍要設定 protocol 並確認實際結果。JA4 也不是可直接填入某個 JA4 Hash 就重建所有封包的設定介面。

這些參數應來自可追溯的封包與明確 schema,避免隨機拼湊不相容的 Cipher、Extension 與 Header。多個 Profile 設定來源同時使用時,也要確認覆寫順序與最終結果。官方自訂 Fingerprint 說明 提供原始字串格式與額外控制項。

Curl Handle、Callback 與傳輸資訊

需要特定 CURLOPT、callback 或 timing 時,可以使用 Low-level Curl。下列程式分開接收 Body 與 Header,並在關閉 handle 前讀取狀態、HTTP 版本與耗時:

from io import BytesIO
from curl_cffi import Curl, CurlInfo, CurlOpt, CurlHttpVersion

body = BytesIO()
headers = BytesIO()
curl = Curl()
try:
    curl.setopt(CurlOpt.URL, "https://httpbin.org/get")
    curl.setopt(CurlOpt.HTTPHEADER, [b"Accept: application/json"])
    curl.setopt(CurlOpt.WRITEDATA, body)
    curl.setopt(CurlOpt.HEADERFUNCTION, headers.write)
    curl.setopt(CurlOpt.TIMEOUT_MS, 15_000)
    curl.perform()
    print({
        "status": curl.getinfo(CurlInfo.RESPONSE_CODE),
        "http_version": CurlHttpVersion(curl.getinfo(CurlInfo.HTTP_VERSION)).name,
        "dns_seconds": curl.getinfo(CurlInfo.NAMELOOKUP_TIME),
        "connect_seconds": curl.getinfo(CurlInfo.CONNECT_TIME),
        "tls_seconds": curl.getinfo(CurlInfo.APPCONNECT_TIME),
        "total_seconds": curl.getinfo(CurlInfo.TOTAL_TIME),
    })
finally:
    curl.close()

這些 timing 多為從傳輸起點計算的累積時間,不能直接當成互不重疊的階段耗時。連線重用也可能讓部分階段為零。需要最終 URL 或遠端 IP 時,可查看 EFFECTIVE_URLPRIMARY_IP,但輸出前要評估其中的 query、內網位址與其他敏感資訊。

高階 Session 可設定 curl_infos=[CurlInfo.TOTAL_TIME, ...],讓套件在 reset 前把指定資訊保存到 response.infos。比起事後讀取可能已被重用的 response.curl,這種做法更符合高階 handle 的生命週期。

低階上傳亦已支援 Python read callback:

from io import BytesIO
from curl_cffi import Curl, CurlInfo, CurlOpt

payload = b"upload via READFUNCTION"
source = BytesIO(payload)
body = BytesIO()
curl = Curl()
try:
    curl.setopt(CurlOpt.URL, "https://httpbin.org/put")
    curl.setopt(CurlOpt.UPLOAD, 1)
    curl.setopt(CurlOpt.INFILESIZE_LARGE, len(payload))
    curl.setopt(CurlOpt.READFUNCTION, source.read)
    curl.setopt(CurlOpt.WRITEDATA, body)
    curl.setopt(CurlOpt.TIMEOUT_MS, 15_000)
    curl.perform()
    assert curl.getinfo(CurlInfo.RESPONSE_CODE) == 200
finally:
    curl.close()

Read callback 接收允許讀取的最大長度,回傳不超過該長度的 bytes,空 bytes 代表 EOF。來源物件與 callback 在傳輸結束前都必須存活;若需要重導或 retry,還要自行處理可回捲條件。0.16.3 Curl 實作 是 Python callback 契約的直接依據。

CLI 與 Scrapy 整合

CLI 適合人工診斷與確認本機 Target:

curl-cffi list --json
curl-cffi get https://tls.browserleaks.com/json --impersonate chrome124

CLI GET 可能印出完整診斷回應,適合在受控終端查看,不應直接將 stdout 當成正式環境的安全 log。curl-cffi update 會向 Profile 服務取得資料並改變本機 cache;重現既有實驗時,應先保存當前版本與 Profile 紀錄。

Scrapy 可透過社群 Download Handler 整合 curl_cffi,例如 scrapy-curl-cffi。整合時需要明確分配 Retry、Cookie、Proxy 與 concurrency 的責任,避免 Scrapy 與 Client 同時重試造成請求倍增,也要核對 adapter 的 TLS 預設:該專案 README 將 verify 預設列為 False,採用前應明確啟用並測試憑證驗證,不能沿用原生 Session 的假設。

Scrapy 用來去重的 Request Fingerprint,與網路層的 TLS Fingerprint 也有不同用途。Scrapy Request Fingerprints 定義的是請求識別與去重規則。

錯誤處理與 Diagnostic Client

錯誤分類與重試條件

HTTP 請求至少經過連線、協定、狀態碼與資料解析四個階段,錯誤處理應保留這些差異:

類型 辨識方式 處理方向
DNS、Connect、傳輸 Timeout RequestException 子類別或 libcurl code 依錯誤與剩餘預算判斷是否重試
憑證、hostname、Proxy 認證 對應 TLS/Proxy error 修正信任或設定,不自動降級驗證
HTTP 429、502、503、504 Response 狀態 視操作是否可重送、Retry-After 與剩餘時間決定
HTTP 400、401、403、404 Response 狀態 通常需要修正資料、權限或路徑,不能盲目重送
JSON/資料契約錯誤 ValueError、欄位型別或必要欄位檢查 與網路錯誤分開,保留可排查原因
輸出檔案失敗 OSError 中止工作,以非零 exit code 表達失敗
工作取消 CancelledError 清理資源並向外傳遞

response.json() 可能使用標準函式庫或其他 JSON backend;捕捉 ValueError 比只假設它一定丟出套件自訂的 JSONDecodeError 更穩妥。對外紀錄應保留錯誤分類、狀態與可重試性,避免直接列印可能包含 URL、Header 或內部路徑的 exception 字串。

0.16.3 提供 Session(retry=...) 與 RetryStrategy,但內建策略不是完整的業務重試政策:它在 RequestException 後重試,不能代替 HTTP status、Retry-After、方法冪等性及整體 deadline 的判斷。POST 在回應前斷線,也可能已經在 Server 完成寫入;重送前需要 idempotency key 或其他服務端保證。0.16.3 Retry 實作 可核對其作用範圍。

完整 Diagnostic Client

配套的 diagnostic.py 將前面的控制整合成可以直接執行的 CLI:有限數量的 GET、固定 Profile、有界併發、Body 上限、重試預算,以及逐筆 JSON Lines 輸出。

git clone https://github.com/hsunAlfred/http-client-fingerprint-examples.git
cd http-client-fingerprint-examples/python-curl-cffi
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
python diagnostic.py \
  --url https://tls.browserleaks.com/json \
  --profile chrome124 \
  --concurrency 2 \
  --output fingerprint-results.jsonl

參數、輸出欄位與 exit code 的完整契約見 範例 README。每筆輸入以 index 對應結果,不把完整 URL 寫入輸出;Profile、HTTP 版本、狀態、耗時與錯誤分類則保留,方便比較同一組測量。

Diagnostic Body 只接受明確的指紋欄位白名單,無效 JSON、非 object 或沒有可用指紋的回應會被分類為失敗。原始 Response、Cookie、Token、User-Agent、IP 或 Server 回顯的任意欄位,不會直接整包寫入 JSONL。若切換診斷服務,需依該服務的 schema 增加明確的 adapter,不能把「收到 HTTP 200」視為已取得有效指紋。

Retry 僅用於可重送的 GET 與選定的暫時性錯誤,並受次數及時間預算限制。Retry-After 超過剩餘時間時,工作停止並輸出失敗,不會把等待時間截短後提早重送。這個 Client 控制的是自身的併發與重試,沒有提供跨程序的每站速率協調;正式服務仍需依 API 限額設定排程與 rate limit。

輸出檔使用排他建立,避免覆寫既有結果。部分請求失敗時仍保留逐筆結果,程序以非零 exit code 提醒呼叫端;檔案寫入失敗則中止,不會只在記憶體中計數後宣稱整批完成。

實測與驗收範圍

範例使用公開 Diagnostic Endpoint、自有 loopback HTTP/WebSocket 與短期測試憑證,沒有對第三方業務網站測試防護繞過。配套檢查可以在安裝測試依賴後重跑:

python -m pip check
python -m unittest -v test_diagnostic.py

TLS/HTTP/2 的公開診斷結果與 HTTP/3 實際協商已記錄於前面的表格;作者另在文章專案驗證 HTTP Body、Session、Cookie、Redirect、Multipart、Timeout、mTLS、串流與 WebSocket。這些直接讀取正文的校驗工具由文章專案維護;公開範例的獨立測試與驗證範圍見 Python README

Proxy 的環境設定行為已用自有 HTTP Proxy 驗證;外部 SOCKS/UDP Proxy、DoH resolver、商業 Profile 下載與 Scrapy Download Handler 的端到端整合,沒有在這組環境實測。這次也沒有執行 Wireshark TLS 解密,或取得同版真實瀏覽器的完整封包對照。

結論

curl_cffi 把 TLS 與 HTTP protocol 的 Profile 控制整合進 Python HTTP API,但可重現的結果仍取決於固定版本、Profile 資料、網路條件與 Server 觀察。Session 管理連線與狀態,AsyncSession 管理非同步傳輸;Streaming、WebSocket 與 Retry 則各自需要大小、時間、併發及生命週期限制。

將這些條件記錄清楚,才能判斷一個差異來自程式設定、底層元件或網路路徑。Transport Profile 相符只支持相應範圍的連線判讀;JavaScript、DOM、瀏覽器歷史與應用程式授權,仍需要各自的執行環境與驗證流程。